Skip to content

feat: AST-based command unfurling for secure mode#21

Merged
sonirico merged 1 commit into
masterfrom
feat/command-unfurling
Jun 14, 2026
Merged

feat: AST-based command unfurling for secure mode#21
sonirico merged 1 commit into
masterfrom
feat/command-unfurling

Conversation

@sonirico

Copy link
Copy Markdown
Owner

Implements the phase-2 design from #19. Replaces secure mode's byte-scanning validator with a shell-AST parser.

Framing: this is a usability / early-reject layer, not a security boundary. OS sandboxing remains the real containment.

What changed

  • unfurl.gocommandUnfurler parses each command with mvdan.cc/sh/v3/syntax and accepts only a single, fully-literal simple command (default-deny): no pipelines, lists, subshells, control flow, redirection, background, inline FOO=bar, command/parameter/arithmetic expansion, brace expansion or globs. Returns the resolved argv.
  • argpolicy.go — per-tool argument policies (git, find, tar) that strip GTFOBins escape hatches structural validation can't see (git -c alias=!…, find -exec, tar --checkpoint-action, …). Interpreters with no governing policy are hard-denied even when allowlisted (previously only a warning).
  • security.go / executor.go — both now consume the same unfurler, removing the previous double-parse (strings.Fields ran in two places — a latent parser-differential). The dead metacharacter helpers are gone.
  • Parser poolingsyntax.Parser is reusable but not concurrency-safe, so parsers are pooled with sync.Pool: low allocations, race-free (the borrowed parser is returned only after the argv copies are built).

Wins

  • Quoted metacharacters are correctly inert: echo ';' → argv ["echo", ";"] (was a false reject).
  • The whole obfuscation / substitution bypass class is rejected structurally, not by an enumerable blocklist.
  • git -c alias.x=!cmd now caught by the git policy; bash/python hard-denied if allowlisted.

Limits (unchanged, stated honestly)

Runtime expansion, eval and Rice's theorem make full malicious-intent detection undecidable. This over-rejects (default-deny) to make safe cases ergonomic and obvious-unsafe cases fail fast.

Test

go build ./... && go vet ./... && go test -race ./... — green on go1.26.4. New unfurl_test.go (accept/reject corpus incl. quoting, expansion, glob, control flow, ANSI-C) and argpolicy_test.go (per-tool); existing bypass corpus stays rejected; parallel subtests exercise the pooled parser under -race.

Closes #19.

Replaces the byte-scanning validator (metacharacter blocklist + strings.Fields)
with a shell-AST parser (mvdan.cc/sh/v3). This is an early-reject layer, not a
security boundary — OS sandboxing remains the real containment.

- unfurl.go: commandUnfurler parses each command and accepts only a single,
  fully-literal simple command (default-deny): no pipelines, lists, subshells,
  control flow, redirections, background, inline assignments, command/parameter/
  arithmetic expansion, brace expansion or globs. It returns the resolved argv.
- argpolicy.go: per-tool argument policies (git, find, tar) that strip the
  GTFOBins escape hatches structural validation can't see (git -c alias=!,
  find -exec, tar --checkpoint-action, ...). Interpreters with no governing
  policy are hard-denied even when allowlisted (was a warning).
- security.go / executor.go: both consume the same unfurler, removing the
  previous double-parse (strings.Fields in two places was a parser-differential).
- Parsers are pooled with sync.Pool: syntax.Parser is reusable but not
  concurrency-safe, so this keeps allocations low while staying race-free.

Wins: quoted metacharacters are correctly treated as inert literals (echo ';'
now works), and the whole obfuscation/substitution bypass class is rejected
structurally rather than by an enumerated blocklist.

Closes #19.
@sonirico sonirico merged commit 6ef72cd into master Jun 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 2: AST-based command unfurling for secure mode

1 participant